/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import { isJsonObject, join, normalize, strings } from '@angular-devkit/core';
import {
  Rule,
  SchematicContext,
  SchematicsException,
  Tree,
  apply,
  applyTemplates,
  chain,
  mergeWith,
  move,
  schematic,
  url,
} from '@angular-devkit/schematics';
import { posix } from 'node:path';
// @ts-ignore
import { Schema as ServerOptions } from './server/schema';
import {
  DependencyType,
  ExistingBehavior,
  InstallBehavior,
  addDependency,
  readWorkspace,
  updateWorkspace,
} from '../../utils/angular';
import { JSONFile } from '../../utils/angular/json-file';
import { latestVersions } from '../../utils/angular/latest-versions';
import { isStandaloneApp } from '../../utils/angular/ng-ast-utils';
import {
  isUsingApplicationBuilder,
  targetBuildNotFoundError,
} from '../../utils/angular/project-targets';
import { getMainFilePath } from '../../utils/angular/standalone/util';
import { getWorkspace } from '../../utils/angular/workspace';

// @ts-ignore
import { Schema as SSROptions } from './schema';

const SERVE_SSR_TARGET_NAME = 'serve-ssr';
const PRERENDER_TARGET_NAME = 'prerender';
const DEFAULT_BROWSER_DIR = 'browser';
const DEFAULT_MEDIA_DIR = 'media';
const DEFAULT_SERVER_DIR = 'server';

async function getLegacyOutputPaths(
  host: Tree,
  projectName: string,
  target: 'server' | 'build',
): Promise<string> {
  // Generate new output paths
  const workspace = await readWorkspace(host);
  const project = workspace.projects.get(projectName);
  const architectTarget = project?.targets.get(target);
  if (!architectTarget?.options) {
    throw new SchematicsException(`Cannot find 'options' for ${projectName} ${target} target.`);
  }

  const { outputPath } = architectTarget.options;
  if (typeof outputPath !== 'string') {
    throw new SchematicsException(
      `outputPath for ${projectName} ${target} target is not a string.`,
    );
  }

  return outputPath;
}

async function getApplicationBuilderOutputPaths(
  host: Tree,
  projectName: string,
): Promise<{ browser: string; server: string; base: string }> {
  // Generate new output paths
  const target = 'build';
  const workspace = await readWorkspace(host);
  const project = workspace.projects.get(projectName);
  const architectTarget = project?.targets.get(target);

  if (!architectTarget?.options) {
    throw new SchematicsException(`Cannot find 'options' for ${projectName} ${target} target.`);
  }

  let { outputPath } = architectTarget.options;
  // Use default if not explicitly specified
  outputPath ??= posix.join('dist', projectName);

  const defaultDirs = {
    server: DEFAULT_SERVER_DIR,
    browser: DEFAULT_BROWSER_DIR,
  };

  if (outputPath && isJsonObject(outputPath)) {
    return {
      ...defaultDirs,
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      ...(outputPath as any),
    };
  }

  if (typeof outputPath !== 'string') {
    throw new SchematicsException(
      `outputPath for ${projectName} ${target} target is not a string.`,
    );
  }

  return {
    base: outputPath,
    ...defaultDirs,
  };
}

function addScriptsRule({ project }: SSROptions, isUsingApplicationBuilder: boolean): Rule {
  return async host => {
    const pkgPath = '/package.json';
    const pkg = host.readJson(pkgPath) as { scripts?: Record<string, string> } | null;
    if (pkg === null) {
      throw new SchematicsException('Could not find package.json');
    }

    if (isUsingApplicationBuilder) {
      const { base, server } = await getApplicationBuilderOutputPaths(host, project);
      pkg.scripts ??= {};
      pkg.scripts[`serve:ssr:${project}`] = `node ${posix.join(base, server)}/server.mjs`;
    } else {
      const serverDist = await getLegacyOutputPaths(host, project, 'server');
      pkg.scripts = {
        ...pkg.scripts,
        'dev:ssr': `ng run ${project}:${SERVE_SSR_TARGET_NAME}`,
        'serve:ssr': `node ${serverDist}/main.js`,
        'build:ssr': `ng build && ng run ${project}:server`,
        prerender: `ng run ${project}:${PRERENDER_TARGET_NAME}`,
      };
    }

    host.overwrite(pkgPath, JSON.stringify(pkg, null, 2));
  };
}

function updateApplicationBuilderTsConfigRule(options: SSROptions): Rule {
  return async host => {
    const workspace = await readWorkspace(host);
    const project = workspace.projects.get(options.project);
    const buildTarget = project?.targets.get('build');
    if (!buildTarget || !buildTarget.options) {
      return;
    }

    const tsConfigPath = buildTarget.options.tsConfig;
    if (!tsConfigPath || typeof tsConfigPath !== 'string') {
      // No tsconfig path
      return;
    }

    const json = new JSONFile(host, tsConfigPath);

    const include = json.get(['include']);
    if (Array.isArray(include) && include.includes('src/**/*.ts')) {
      return;
    }

    const filesPath = ['files'];
    const files = new Set((json.get(filesPath) as string[] | undefined) ?? []);
    files.add('src/server.ts');
    json.modify(filesPath, [...files]);
  };
}

function updateRootTsConfigRule(options: SSROptions): Rule {
  return async (host: Tree) => {
    const workspace = await readWorkspace(host);
    const project = workspace.projects.get(options.project);

    let tsConfigPath: string | undefined = options.tsconfigPath;

    if (!tsConfigPath && project) {
      const projRoot = normalize(project.root || '');
      const candidate = projRoot ? join(projRoot, 'tsconfig.json') : 'tsconfig.json';
      if (host.exists(candidate)) {
        tsConfigPath = candidate;
      }
    }

    if (!tsConfigPath && host.exists('tsconfig.json')) {
      tsConfigPath = 'tsconfig.json';
    }

    if (!tsConfigPath || !host.exists(tsConfigPath)) {
      return;
    }

    const json = new JSONFile(host, tsConfigPath);

    const moduleResolutionPath = ['compilerOptions', 'moduleResolution'];
    const modulePath = ['compilerOptions', 'module'];

    const currentModuleResolution = json.get(moduleResolutionPath);
    if (currentModuleResolution !== 'bundler') {
      json.modify(moduleResolutionPath, 'bundler');
    }
    const currentModule = json.get(modulePath);
    if (currentModule !== 'preserve') {
      json.modify(modulePath, 'preserve');
    }
  };
}

export function updateIndexHtml(options: SSROptions): Rule {
  return async (host: Tree) => {
    const workspace = await getWorkspace(host);
    const project = workspace.projects.get(options.project);

    if (!project) {
      return;
    }

    const buildOptions = project.targets.get('build')?.options;
    const indexPath = buildOptions?.index as string;

    if (!indexPath || !host.exists(indexPath)) {
      return;
    }

    const buffer = host.read(indexPath);
    if (!buffer) return;
    const content = buffer.toString('utf-8');

    const loaderDiv = `<div id="lp-page-loader"></div>`;
    let updatedContent = content.replace(loaderDiv, '');
    host.overwrite(indexPath, updatedContent);
  };
}


function updateApplicationBuilderWorkspaceConfigRule(
  projectSourceRoot: string,
  options: SSROptions,
  { logger }: SchematicContext,
): Rule {
  return updateWorkspace(workspace => {
    const buildTarget = workspace.projects.get(options.project)?.targets.get('build');
    if (!buildTarget) {
      return;
    }

    let outputPath = buildTarget.options?.outputPath;
    if (outputPath && isJsonObject(outputPath)) {
      if (outputPath.browser === '') {
        const base = outputPath.base as string;
        logger.warn(
          `The output location of the browser build has been updated from "${base}" to "${posix.join(
            base,
            DEFAULT_BROWSER_DIR,
          )}".
          You might need to adjust your deployment pipeline.`,
        );

        if (
          (outputPath.media && outputPath.media !== DEFAULT_MEDIA_DIR) ||
          (outputPath.server && outputPath.server !== DEFAULT_SERVER_DIR)
        ) {
          delete outputPath.browser;
        } else {
          outputPath = outputPath.base;
        }
      }
    }

    buildTarget.options = {
      ...buildTarget.options,
      outputPath,
      outputMode: 'server',
      ssr: {
        entry: join(normalize(projectSourceRoot), 'server.ts'),
      },
    };
  });
}

function updateWebpackBuilderWorkspaceConfigRule(
  projectSourceRoot: string,
  options: SSROptions,
): Rule {
  return updateWorkspace(workspace => {
    const projectName = options.project;
    const project = workspace.projects.get(projectName);
    if (!project) {
      return;
    }

    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    const serverTarget = project.targets.get('server')!;
    (serverTarget.options ??= {}).main = posix.join(projectSourceRoot, 'server.ts');

    const serveSSRTarget = project.targets.get(SERVE_SSR_TARGET_NAME);
    if (serveSSRTarget) {
      return;
    }

    project.targets.add({
      name: SERVE_SSR_TARGET_NAME,
      builder: '@angular-devkit/build-angular:ssr-dev-server',
      defaultConfiguration: 'development',
      options: {},
      configurations: {
        development: {
          browserTarget: `${projectName}:build:development`,
          serverTarget: `${projectName}:server:development`,
        },
        production: {
          browserTarget: `${projectName}:build:production`,
          serverTarget: `${projectName}:server:production`,
        },
      },
    });

    const prerenderTarget = project.targets.get(PRERENDER_TARGET_NAME);
    if (prerenderTarget) {
      return;
    }

    project.targets.add({
      name: PRERENDER_TARGET_NAME,
      builder: '@angular-devkit/build-angular:prerender',
      defaultConfiguration: 'production',
      options: {
        routes: ['/'],
      },
      configurations: {
        production: {
          browserTarget: `${projectName}:build:production`,
          serverTarget: `${projectName}:server:production`,
        },
        development: {
          browserTarget: `${projectName}:build:development`,
          serverTarget: `${projectName}:server:development`,
        },
      },
    });
  });
}

function updateWebpackBuilderServerTsConfigRule(options: SSROptions): Rule {
  return async host => {
    const workspace = await readWorkspace(host);
    const project = workspace.projects.get(options.project);
    const serverTarget = project?.targets.get('server');
    if (!serverTarget || !serverTarget.options) {
      return;
    }

    const tsConfigPath = serverTarget.options.tsConfig;
    if (!tsConfigPath || typeof tsConfigPath !== 'string') {
      // No tsconfig path
      return;
    }

    const tsConfig = new JSONFile(host, tsConfigPath);
    const filesAstNode = tsConfig.get(['files']);
    const serverFilePath = 'src/server.ts';
    if (Array.isArray(filesAstNode) && !filesAstNode.some(({ text }) => text === serverFilePath)) {
      tsConfig.modify(['files'], [...filesAstNode, serverFilePath]);
    }
  };
}

function addDependencies({ skipInstall }: SSROptions, isUsingApplicationBuilder: boolean): Rule {
  const install = skipInstall ? InstallBehavior.None : InstallBehavior.Auto;

  const rules: Rule[] = [
    addDependency('express', latestVersions.dependencies.express, {
      type: DependencyType.Default,
      install,
      existing: ExistingBehavior.Replace,
    }),
    addDependency('@types/express', latestVersions.dependencies['@types/express'], {
      type: DependencyType.Dev,
      install,
      existing: ExistingBehavior.Replace,
    }),
    addDependency('openid-client', latestVersions.dependencies['openid-client'], {
      type: DependencyType.Default,
      install,
      existing: ExistingBehavior.Skip,
    }),
  ];

  if (!isUsingApplicationBuilder) {
    rules.push(
      addDependency('browser-sync', latestVersions.dependencies['browser-sync'], {
        type: DependencyType.Dev,
        install,
      }),
    );
  }

  return chain(rules);
}

function addServerFile(
  projectSourceRoot: string,
  options: ServerOptions,
  isStandalone: boolean,
): Rule {
  return async host => {
    const projectName = options.project;
    const workspace = await readWorkspace(host);
    const project = workspace.projects.get(projectName);
    if (!project) {
      throw new SchematicsException(`Invalid project name (${projectName})`);
    }
    const usingApplicationBuilder = isUsingApplicationBuilder(project);
    const browserDistDirectory = usingApplicationBuilder
      ? (await getApplicationBuilderOutputPaths(host, projectName)).browser
      : await getLegacyOutputPaths(host, projectName, 'build');

    return mergeWith(
      apply(url(`./files/${usingApplicationBuilder ? 'application-builder' : 'server-builder'}`), [
        applyTemplates({
          ...strings,
          ...options,
          browserDistDirectory,
          isStandalone,
        }),
        move(projectSourceRoot),
      ]),
    );
  };
}

export default function (options: SSROptions): Rule {
  return async (host, context) => {
    const browserEntryPoint = await getMainFilePath(host, options.project);
    const isStandalone = isStandaloneApp(host, browserEntryPoint);

    const workspace = await getWorkspace(host);
    const clientProject = workspace.projects.get(options.project);
    if (!clientProject) {
      throw targetBuildNotFoundError();
    }

    const usingApplicationBuilder = isUsingApplicationBuilder(clientProject);
    const sourceRoot = clientProject.sourceRoot ?? posix.join(clientProject.root, 'src');

    return chain([
      schematic('server', {
        ...options,
        skipInstall: true,
      }),
      ...(usingApplicationBuilder
        ? [
            updateApplicationBuilderWorkspaceConfigRule(sourceRoot, options, context),
            updateApplicationBuilderTsConfigRule(options),
            updateRootTsConfigRule(options)
          ]
        : [
            updateWebpackBuilderServerTsConfigRule(options),
            updateWebpackBuilderWorkspaceConfigRule(sourceRoot, options),
          ]),
      addServerFile(sourceRoot, options, isStandalone),
      addScriptsRule(options, usingApplicationBuilder),
      addDependencies(options, usingApplicationBuilder),
      updateIndexHtml(options),
    ]);
  };
}
